Skip to content

feat(release): publish the yank list weekly, serve both lists, attach them to releases (#226, part 2) - #237

Merged
Madraka merged 4 commits into
mainfrom
claude/release-trust-226-publish
Nov 14, 2026
Merged

Madraka merged 4 commits into
mainfrom
claude/release-trust-226-publish

Conversation

@Madraka

@Madraka Madraka commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #235 (base claude/release-trust-226). Merge #235 first; GitHub then retargets this PR to main.

Owner steps (after #235's ceremony steps 1 to 4; full commands in the runbook › First-time setup)

  1. Publish the key list. Commit security/keys.json, its .sig and security/keys.source.json in a reviewed PR.
  2. Deploy key and ruleset on yank-list. Generate an Ed25519 deploy key (ssh-keygen -t ed25519 -N "" -C yank-list), add its public half as a deploy key with write access, and set the private half as the release-yank secret YANK_LIST_DEPLOY_KEY. Then, in the repository settings › Rules, add a branch ruleset for yank-list: block force pushes, restrict deletions, and restrict updates with the yank-list deploy key as the only bypass (not "GitHub Actions"). Merge security/keys.json only while no release is pending, and do this step and the next ones straight after (runbook, steps 6–9).
  3. First yank list. Run Actions › Yank list › Run workflow with first_run checked. It creates the yank-list branch at serial 1, and from then on it runs every Monday.
  4. Deploy cloudhal.dev once. Run pnpm --filter @cloudhal/docs run deploy. This ships the /.well-known/cloudhal/yanked.json route and the static keys.json. Nothing deploys cloudhal.dev automatically yet (cloudhal.dev: deploy on push to main (RFC-0037) #236), so redeploy after every security/keys.json change too.

Nothing here generates keys, creates environments or secrets, or runs a workflow.

What (PM decision A, with its conditions)

  • .github/workflows/yank-list.yml. It runs weekly (Mondays 04:17 UTC) and on workflow_dispatch with yank, revoke, reason and first_run.
    • Least privilege: top-level permissions: {}, read-only tokens (the push uses release-yank's deploy key), issues: write only on the failure notice. Actions are pinned by SHA.
    • One install: exactly @cloudhal/release-verify's pinned dependencies, with --ignore-scripts, before the key is used. That lets sign-yanked verify the exact bytes it signs.
    • The key: RELEASE_YANK_KEY reaches the Sign step only, which runs scripts/release-keys.ts, Node built-ins only.
    • Inputs reach the script only through env (no ${{ }} in any run), and the script validates them: a version or key id pattern, and a reason that is required with at most 500 characters.
    • Publishing: it reads the published list from the yank-list branch, signs, and pushes one new commit. It never force-pushes.
    • test/yank-list-workflow.test.ts checks all of this.
  • sign-yanked. The list it signs is the security/yank-list.json source, plus every entry of the published list (carried forward, so a yank or revocation is never dropped), plus the emergency entry. serial is the published serial plus one (1 on the first run).
  • cloudhal.dev:
    • The proxy (apps/docs/src/worker/trust.ts). It serves exactly /.well-known/cloudhal/yanked.json and .sig.
      • Upstream: a fixed URL, raw.githubusercontent.com/cloudhal-dev/cloudhal/yank-list/<file>, with no part of the request in it.
      • Caching: cf.cacheTtl 300, and Cache-Control: max-age=300.
      • Bytes: passed through unchanged, with application/json or application/octet-stream.
      • Failure: a non-200 answer, a thrown fetch or an oversized body gives 502 with no-store, never an old copy. Only GET and HEAD are allowed.
    • keys.json is a static asset: postbuild copies security/keys.json(.sig) to /.well-known/cloudhal/ once they exist, and _headers adds CORS and the .sig type.
  • Release assets. publish-ci attaches keys.json, yanked.json and their .sig, verified with release-verify before upload (scripts/release-trust-assets.ts).
    • Nothing is attached before the root ceremony.
    • A resumed run of an already-published, immutable release expects the trust assets it carries and never refetches newer copies.
    • The release-verify import is lazy, so --plan still runs without an install (checked against live npm).
    • RFC-0046 §1 lists the snapshot.
  • Rollback mitigation (the signed serial and lastSerial are in feat(release): signed key list, yank list and key expiry (#226, part 1) #235): the branch is append-only under the ruleset. RFC §9 and the runbook say that a stateless Worker consumer can't detect a rollback inside the 30 days.
  • Runbook: the ruleset, the first run, the manual docs deploy, and emergency yank or revoke through the workflow inputs (record the entry in security/yank-list.json afterwards; published entries are carried forward anyway).
  • Follow-up: RFC-0037's missing "push to main deploys cloudhal-dev" is cloudhal.dev: deploy on push to main (RFC-0037) #236. It is not built here.

Rebased onto #235 round 1 (a35034d)

  • The workflow's verifier-only install, as above.
  • trustAssets returns the verified yank list, and publish-ci runs the consumer check of the bundle with it, before any upload.
  • The runbook merges feat(release): signed key list, yank list and key expiry (#226, part 1) #235's ceremony order (keys.json committed before the first yank list) with this PR's steps.
  • Tests: release-trust-assets 4, yank-list-workflow 4, release-keys 14, and publish, plan, workflows, sign and verify all green. Docs 39. --plan gives publish=false, and biome, size and drift are clean.

Review round (1aa446c, rebased onto #235 6902752)

  • The published list is verified before it is carried forward (scripts/release-keys/yank-list.ts):
    • the signature by the active yank key of the pinned-root keys.json, the schema and freshness;
    • a crafted, edited or foreign-signed list fails the run loudly, and nothing is written;
    • --rotate (dispatch input rotate) starts a new yank key's list from the source at serial 1;
    • --accept-expired carries forward a list that expired during an outage, still verified as of its issue.
  • Ruleset bypass: the push uses a deploy key held only by release-yank (YANK_LIST_DEPLOY_KEY, Publish step only, pinned SSH host key). It is the ruleset's only bypass, so no job token has contents: write. The runbook states what remains exposed.
  • Pair-consistent serving:
    • the docs Worker fetches both files uncached;
    • it serves them only if the signature verifies with the yank key of the static keys.json, cached as one entry for 5 minutes;
    • on a mismatch or failure it serves the last good pair, else 502, never a mismatch;
    • both halves carry the same etag and cache-control: no-cache.
  • publish-ci: trustAssets returns the verified keys too, and verifyAsConsumer applies mergeTrust(pinned, { keys, yanked }). A key revoked in either list, or a yanked version, blocks the publish (tested).
  • Dispatches:
    • comma-separated, validated versions and key ids (at most 20 per run);
    • inputs passed as --name=value;
    • the runbook says to wait for a running run first;
    • a job summary, plus a notify job (issues: write only) that opens or comments on "Yank list run failed" when a run fails or is cancelled.
  • Lows:
    • a check job skips with a notice outside cloudhal-dev/cloudhal and before security/keys.json exists, so the environment is not even referenced before then;
    • stale comments fixed;
    • runbook step 6 is merged only with no release pending, and steps 7–9 follow straight after.
  • Tests:
    • cli: release-keys 19, release-trust-assets 4, yank-list-workflow 6, release-bundle-publish 10, release-publish-plan 17, release-plan 9, release-workflows 14, release-bundle-sign 5, release-bundle-verify 12, release-bundle 18;
    • release-verify 84 (1 skipped), docs 41;
    • typechecks, biome, size and actionlint are clean; --plan gives publish=false.

Tests (run locally, one at a time)

  • apps/docs: 39 passed, including the new trust.test.ts (4): fixed URLs that ignore the request's query, the cache settings, bytes passed through, 502 on 404, 503, a thrown fetch or an oversized body, GET and HEAD only, and the Worker routing only the two paths. Typecheck is clean, and the docs build passes (25 schemas, no .well-known before the ceremony).
  • packages/cli:
    • release-keys 10 (merging, emergency entries, input validation, serial);
    • release-trust-assets 4 (none before the ceremony; attach verified; refuse unverifiable or unreachable; a published release keeps its own);
    • yank-list-workflow 4;
    • release-bundle-publish 9, release-publish-plan 17, release-plan 9, release-workflows 14 and release-bundle-sign 5.
  • Checks: publish-ci --plan runs; biome, size:check and the cli typecheck are clean.

Closes #226

🤖 Generated with Claude Code

@Madraka
Madraka force-pushed the claude/release-trust-226-publish branch from 970f9b4 to a35034d Compare November 14, 2026 14:55
@Madraka
Madraka force-pushed the claude/release-trust-226-publish branch from a35034d to 1aa446c Compare November 14, 2026 15:17
Madraka added a commit that referenced this pull request Nov 14, 2026
…fied pair (#226)

- sign-yanked verifies the published yanked.json (and .sig) as a consumer
  would before carrying anything forward, and fails loudly otherwise.
  --rotate starts a new yank key's list from the source at serial 1;
  --accept-expired carries forward a list that expired in an outage, still
  verified. The yank-list logic moves to scripts/release-keys/yank-list.ts.
- Emergency entries are comma-separated and validated (at most 20 a run).
- yank-list.yml: a check job skips before security/keys.json exists and
  outside cloudhal-dev/cloudhal; inputs go as --name=value; rotate and
  accept_expired inputs; the push uses a deploy key held by release-yank
  (the ruleset's only bypass), so every token is read-only; a job summary
  and an issue on a failed or cancelled run.
- The docs Worker serves yanked.json and .sig only as a pair whose signature
  verifies with the static keys.json's yank key, cached as one entry; the
  last good pair or 502 on a mismatch, never a mismatch.
- publish-ci's consumer check applies mergeTrust with the verified key and
  yank lists; trustAssets returns both.
- Runbook: deploy-key ruleset and remaining exposure, yank-key rotation,
  multi-entry dispatches, merge step 6 with no release pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka force-pushed the claude/release-trust-226-publish branch from 1aa446c to a392d88 Compare November 14, 2026 15:29
Madraka added a commit that referenced this pull request Nov 14, 2026
…fied pair (#226)

- sign-yanked verifies the published yanked.json (and .sig) as a consumer
  would before carrying anything forward, and fails loudly otherwise.
  --rotate starts a new yank key's list from the source at serial 1;
  --accept-expired carries forward a list that expired in an outage, still
  verified. The yank-list logic moves to scripts/release-keys/yank-list.ts.
- Emergency entries are comma-separated and validated (at most 20 a run).
- yank-list.yml: a check job skips before security/keys.json exists and
  outside cloudhal-dev/cloudhal; inputs go as --name=value; rotate and
  accept_expired inputs; the push uses a deploy key held by release-yank
  (the ruleset's only bypass), so every token is read-only; a job summary
  and an issue on a failed or cancelled run.
- The docs Worker serves yanked.json and .sig only as a pair whose signature
  verifies with the static keys.json's yank key, cached as one entry; the
  last good pair or 502 on a mismatch, never a mismatch.
- publish-ci's consumer check applies mergeTrust with the verified key and
  yank lists; trustAssets returns both.
- Runbook: deploy-key ruleset and remaining exposure, yank-key rotation,
  multi-entry dispatches, merge step 6 with no release pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka and others added 4 commits November 14, 2026 18:35
… them to releases (#226, part 2)

PM decision A, with its conditions:

- .github/workflows/yank-list.yml: weekly (Mondays) and on demand (yank,
  revoke, reason, first_run).
  - It signs security/yank-list.json, plus every published entry
    (carried forward), plus an emergency entry, with serial + 1.
  - It commits yanked.json(.sig) to the append-only `yank-list` branch:
    never force-pushed, contents: write on its one job, release-yank
    environment, actions pinned by SHA, no install, inputs only through
    env, and the key handed to one step.
- scripts/release-keys.ts sign-yanked: --yank/--revoke/--reason
  (validated, with the reason required); the merge carries published
  entries forward; a first run without a published file works.
- cloudhal.dev:
  - the Worker serves /.well-known/cloudhal/yanked.json(.sig) by fetching
    exactly the branch's raw file from a fixed URL, cached for at most
    5 min, bytes unchanged, 502 on any upstream failure;
  - postbuild copies security/keys.json(.sig) to /.well-known/cloudhal/
    once it exists, and _headers adds CORS and the .sig type.
- publish-ci attaches keys.json, yanked.json and their .sig to each
  release, verified first (none before the root ceremony; a resumed run
  of a published release reuses what it carries). RFC-0046 §1 lists them.
- Runbook: the yank-list ruleset, the first run, the one manual docs
  deploy, and emergency yank or revoke through the workflow inputs.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
- yank-list.yml installs exactly @cloudhal/release-verify's pinned
  dependencies, with --ignore-scripts and before the key reaches any
  step, so sign-yanked verifies the exact bytes it signs (keys.json
  against the pinned root, the published list as memory). The test
  allows only that install.
- trustAssets returns the verified yank list too. publish-ci now runs
  the consumer check of the bundle after it, with that list, so a yanked
  version or a key the list revokes is refused before anything is
  uploaded.
- The trust-asset tests use the async signers.

Refs #226

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
…fied pair (#226)

- sign-yanked verifies the published yanked.json (and .sig) as a consumer
  would before carrying anything forward, and fails loudly otherwise.
  --rotate starts a new yank key's list from the source at serial 1;
  --accept-expired carries forward a list that expired in an outage, still
  verified. The yank-list logic moves to scripts/release-keys/yank-list.ts.
- Emergency entries are comma-separated and validated (at most 20 a run).
- yank-list.yml: a check job skips before security/keys.json exists and
  outside cloudhal-dev/cloudhal; inputs go as --name=value; rotate and
  accept_expired inputs; the push uses a deploy key held by release-yank
  (the ruleset's only bypass), so every token is read-only; a job summary
  and an issue on a failed or cancelled run.
- The docs Worker serves yanked.json and .sig only as a pair whose signature
  verifies with the static keys.json's yank key, cached as one entry; the
  last good pair or 502 on a mismatch, never a mismatch.
- publish-ci's consumer check applies mergeTrust with the verified key and
  yank lists; trustAssets returns both.
- Runbook: deploy-key ruleset and remaining exposure, yank-key rotation,
  multi-entry dispatches, merge step 6 with no release pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
When the published list is signed by another yank key than --key-id, it is
verified with that key as the newer keys.json still lists it (revoked or
retiring), as of its own issuedAt; every entry is carried forward and the
serial continues. Its memory names no keys.json (the epoch), so the new key's
first list passes the self-check. A crafted list fails the old key's
signature; an unlisted signer is refused. Only --rotate prunes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka force-pushed the claude/release-trust-226-publish branch from a392d88 to c2e4149 Compare November 14, 2026 15:36
Base automatically changed from claude/release-trust-226 to main November 14, 2026 15:59
@Madraka
Madraka merged commit 623ecce into main Nov 14, 2026
5 checks passed
@Madraka
Madraka deleted the claude/release-trust-226-publish branch November 14, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9)

1 participant